agentmux_srv\backend\storage/
registry_mirror.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Cross-version named-agent registry mirror.
5//!
6//! Mirrors `db_agent_instances` mutations into the JSON registry at
7//! `~/.agentmux/agents/registry/` so other AgentMux versions running
8//! on the same machine see each other's named agents in their launch
9//! modal dropdowns. SQLite is authoritative for the local version;
10//! the registry is a best-effort cross-version view.
11//!
12//! Extracted from `store.rs` in Phase R.6 of the storage
13//! modularization plan
14//! (`docs/specs/SPEC_STORE_MODULARIZATION_2026_05_27.md`). This whole
15//! file retires when Phase R sunsets the JSON registry entirely;
16//! isolating it now makes that future deletion a clean unit.
17
18use std::path::Path;
19
20use super::store::{AgentInstance, Store};
21
22/// Build a registry record from an `AgentInstance`. Returns an error
23/// if the working directory can't be expressed as a path relative to
24/// the canonical shared agents root (e.g. user pointed an agent at
25/// `~/projects/foo`, which would also fail a naive `"agents"`
26/// segment-scan that happened to match `~/projects/agents/foo`).
27/// Caller logs + skips — agent stays in SQLite, just not in the
28/// cross-version dropdown.
29fn agent_instance_to_record(
30    inst: &AgentInstance,
31    global_agents_root: Option<&Path>,
32    channel_agents_base: &Path,
33) -> Result<crate::registry::NamedAgentRecord, String> {
34    use crate::registry::{NamedAgentRecord, NamedAgentRecordV1};
35    // Agent workspaces are GLOBAL (`<home>/agents/<name>`), so anchor on that
36    // global root FIRST, falling back to this channel's agents dir only for a
37    // legacy in-channel workspace. This MUST match `registry/migrate.rs`'s
38    // `row_to_record` — the one-shot migration and this live mirror have to stamp
39    // the same `source_agents_base` for a given agent or a reader sees two
40    // different bases. Without the global branch, every real (global) workspace
41    // failed strip_prefix and the agent was dropped from the registry ("not
42    // representable") — the live-write twin of the bug #1393 fixed in the
43    // migration.
44    let (rel, base): (String, &Path) = global_agents_root
45        .and_then(|g| relative_workdir(&inst.working_directory, g).map(|r| (r, g)))
46        .or_else(|| {
47            relative_workdir(&inst.working_directory, channel_agents_base)
48                .map(|r| (r, channel_agents_base))
49        })
50        .ok_or_else(|| {
51            format!(
52                "working_directory {:?} is under neither the global agents root {:?} nor the channel base {:?}",
53                inst.working_directory,
54                global_agents_root.map(|p| p.display().to_string()),
55                channel_agents_base.display()
56            )
57        })?;
58    let version = env!("CARGO_PKG_VERSION").to_string();
59    let data = NamedAgentRecordV1 {
60        instance_id: inst.id.clone(),
61        instance_name: inst.instance_name.clone(),
62        definition_id: inst.definition_id.clone(),
63        identity_id: empty_to_none(&inst.identity_id),
64        memory_id: empty_to_none(&inst.memory_id),
65        // v2: carry the provider session id so a global/cross-channel
66        // record can `--resume` without joining the current channel's SQLite.
67        session_id: empty_to_none(&inst.session_id),
68        working_dir: rel,
69        // v3: the agents dir `working_dir` is relative to — the GLOBAL workspace
70        // root for a normal agent, or this channel's agents dir for a legacy
71        // in-channel workspace. Stored absolute so any channel reconstructs the
72        // path via `source_agents_base.join(working_dir)` (P0.4).
73        source_agents_base: Some(base.to_string_lossy().to_string()),
74        created_at_ms: inst.created_at,
75        last_launched_at_ms: inst.started_at,
76        created_by_version: version.clone(),
77        last_launched_by_version: version,
78    };
79    // Stamp the lowest envelope schema that faithfully represents the payload
80    // (schema::min_schema_version). Since P0.4 always sets source_agents_base,
81    // that is v3 for every live-mirrored record — a pre-v3 reader rejects it
82    // (intended: better to hide than mis-resolve a cross-channel workdir; the
83    // only pre-v3 readers of the GLOBAL registry are pre-P0.4 builds, which
84    // ship together with this).
85    Ok(NamedAgentRecord {
86        schema_version: data.min_schema_version(),
87        data,
88    })
89}
90
91fn empty_to_none(s: &str) -> Option<String> {
92    if s.is_empty() {
93        None
94    } else {
95        Some(s.to_string())
96    }
97}
98
99/// Express `abs` as a path relative to `agents_root`. Returns `None`
100/// when `abs` is empty, not under `agents_root`, or after stripping
101/// resolves to an empty path. Anchors against the **resolved** shared
102/// root (passed in by the caller) — never scans for a path segment
103/// named "agents", which would match unrelated user directories like
104/// `/home/me/projects/agents/foo`.
105fn relative_workdir(abs: &str, agents_root: &Path) -> Option<String> {
106    if abs.is_empty() {
107        return None;
108    }
109    let p = std::path::Path::new(abs);
110    let rel = p.strip_prefix(agents_root).ok()?;
111    // Reject empties + traversals (defense in depth — strip_prefix
112    // already rules out `..` escapes, but the registry's own validator
113    // re-checks).
114    let s = rel.to_string_lossy().to_string();
115    if s.is_empty() || s == "." {
116        return None;
117    }
118    Some(s)
119}
120
121impl Store {
122    /// Mirror a `db_agent_instances` mutation into the cross-version
123    /// registry. Only fires for **named** rows. Routes by
124    /// `display_hidden` so the registry file ends up in the tree
125    /// matching SQLite's dropdown filter:
126    ///
127    /// - hidden = true  → upsert (atomic write to active/) then
128    ///   retire (atomic rename to retired/). Net: file lives in
129    ///   `retired/<id>.json` with the freshest content. Prevents
130    ///   `instance_update` on a previously-hidden row from
131    ///   resurrecting an active registry file, AND keeps the
132    ///   retired tombstone's content current.
133    /// - hidden = false → unretire (no-op if not retired) then
134    ///   upsert. Net: file in `active/<id>.json`, no orphan retired.
135    ///
136    /// Failures are logged, never propagated: SQLite remains
137    /// authoritative.
138    pub(super) fn registry_upsert_if_named(&self, inst: &AgentInstance) {
139        // Mirror filter: only registers named rows. Pre-Option-E this
140        // also excluded continuation rows (parent_instance_id != '')
141        // so the registry-sourced read path wouldn't surface chained
142        // resumes as duplicate dropdown rows. Under Option E, the
143        // session zone is anchored on definition_id, so a continuation
144        // row IS the most-recent named instance — exactly what we want
145        // visible. `instance_list_named` (the SQLite-sourced read
146        // path) dropped its parent_instance_id filter in the
147        // 2026-05-24 picker-visibility fix; the registry mirror keeps
148        // its filter here for now since the registry-sourced read path
149        // doesn't have the dedup-by-(definition_id, instance_name)
150        // affordance the SQLite ORDER BY/LIMIT provides. Follow-up
151        // PR will land cross-version dedup so this filter can drop too.
152        if inst.instance_name.is_empty() || !inst.parent_instance_id.is_empty() {
153            return;
154        }
155        let Some(reg) = self.shared_agent_registry() else {
156            return;
157        };
158        // Anchor the relative working_directory on the CURRENT channel's
159        // agents dir, not the registry's own parent — once P0.3 re-roots the
160        // registry under ~/.agentmux/shared/, its parent no longer coincides
161        // with channels/<ch>/agents/. `registry_agents_base()` returns the
162        // explicit channel dir (AGENTMUX_AGENTS_DIR) and falls back to the
163        // registry parent for tests / pre-re-root layouts.
164        let Some(agents_root) = self.registry_agents_base() else {
165            tracing::warn!("registry: no agents base — skipping mirror");
166            return;
167        };
168        // Agent workspaces are GLOBAL (`<home>/agents/<name>`). Derive `<home>`
169        // from the registry root exactly as main.rs does for the one-shot
170        // migration (registry = `<home>/shared/agents/registry` → `nth(3)` strips
171        // registry→agents→shared = `<home>`), so the live mirror and the migration
172        // anchor identically. `None` in shallow test/pre-re-root layouts → the
173        // mirror falls back to the per-channel base.
174        let global_agents_root = reg.root().ancestors().nth(3).map(|h| h.join("agents"));
175        let rec = match agent_instance_to_record(inst, global_agents_root.as_deref(), &agents_root) {
176            Ok(rec) => rec,
177            Err(e) => {
178                tracing::warn!(
179                    instance_id = %inst.id,
180                    error = %e,
181                    "registry: instance not representable as record, skipping mirror"
182                );
183                return;
184            }
185        };
186
187        // If the row was previously hidden, the file lives in
188        // `retired/`. Move it back to active before upserting so
189        // upsert's merge-preserves-unknown-fields path operates on
190        // the right file (and we never leave dangling retired files).
191        if let Err(e) = reg.unretire(&inst.id) {
192            tracing::warn!(
193                instance_id = %inst.id,
194                error = %e,
195                "registry: failed to unretire row before upsert"
196            );
197        }
198
199        if let Err(e) = reg.upsert(&rec) {
200            tracing::warn!(
201                instance_id = %inst.id,
202                error = %e,
203                "registry: failed to mirror instance_create/update"
204            );
205            return;
206        }
207
208        // After the upsert, move into retired/ if the row is hidden.
209        // Combined: hidden row's tombstone always has up-to-date
210        // content, and active/ never carries a hidden row.
211        if inst.display_hidden {
212            if let Err(e) = reg.retire(&inst.id) {
213                tracing::warn!(
214                    instance_id = %inst.id,
215                    error = %e,
216                    "registry: failed to retire hidden row post-upsert"
217                );
218            }
219        }
220    }
221}